Skip to content

Service Accounts#3058

Open
GregorShear wants to merge 9 commits into
masterfrom
greg/service-accounts-v2
Open

Service Accounts#3058
GregorShear wants to merge 9 commits into
masterfrom
greg/service-accounts-v2

Conversation

@GregorShear

@GregorShear GregorShear commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Service accounts

Non-login identities for CI/CD and automation. A service account is a real auth.users row authorized through ordinary user_grants, so RLS, user_roles() resolution, and role traversal work unchanged. It's addressed by a unique catalog name (e.g. acmeCo/ci-bot) that acts as a management anchor: holders of the new service-account capabilities on a covering prefix manage the account. The account's own access comes solely from its grants, which may span prefixes.

Schema: new internal.service_accounts table (unique catalog_name, SP-GiST-indexed for prefix scoping) and a refresh_tokens.created_by column recording the minting admin.

API keys are refresh tokens

There is no bespoke credential type. createApiKey mints a multi-use refresh token owned by the account and returns the bearer secret exactly once, in the base64 {id, secret} form that Authorization: Bearer and POST /api/v1/auth/token already accept. Revoking zeroes valid_for: the token becomes inert immediately (every request re-verifies against the database) while the row survives for audit. revokeAllApiKeys is the credential kill switch.

Service accounts cannot self-manage credentials: createRefreshToken / revokeRefreshToken reject service-account callers, so a leaked key can't mint its own replacements or revoke outside the admin flow. (A residual self-service surface exists via PostgREST's refresh_tokens grants; it disappears with PostgREST.)

Authorization

Four new fine-grained capabilities (QueryServiceAccounts, CreateServiceAccount, CreateApiKey, RevokeApiKey), bundled as manage_service_account and included in team_admin:

Operation Requires
serviceAccounts query QueryServiceAccounts on a covering prefix
createServiceAccount CreateServiceAccount on the catalog name, plus CreateGrant on each seeded grant prefix
addServiceAccountGrant CreateGrant on the granted prefix
removeServiceAccountGrant / removeAllServiceAccountGrants CreateServiceAccount on the catalog name
createApiKey CreateApiKey on the catalog name
revokeApiKey / revokeAllApiKeys RevokeApiKey on the catalog name

The per-prefix CreateGrant check on add is the anti-escalation guard: a manager can't hand an account reach they couldn't grant anyone. Removal has no per-prefix requirement since it only narrows access. Re-adding an existing grant overwrites its capability, so downgrades work in one call.

Also in this PR

  • New Sensitive GraphQL scalar for one-time secrets: serializes as a string, redacting Debug, and distinct in the schema so client tooling can recognize and redact it. Applied to API-key, refresh-token, and Stripe setup-intent secrets.
  • The bearer-credential auth path and POST /api/v1/auth/token now share one generate_access_token, so credential-error sanitization lives in one place.
  • Stripe billing-contact selection excludes service accounts, whose synthetic @service_accounts.estuary.dev addresses must never become a tenant's billing contact.
  • CI: the flaky Stripe integration test runs last so it can't skip later steps (it still gates the job).

Testing

sqlx tests cover the lifecycle end to end: create with multi-grant seeding, mint, list (including derived lastUsedAt), revoke and kill switches with idempotency, self-management rejection, duplicate catalog names, validFor validation, and grant add/remove/overwrite semantics. A second test verifies a team_admin holder without full Admin can manage accounts while CreateGrant still bounds escalation. The JWT-signing exchange path is covered by existing pgTAP tests.

@GregorShear GregorShear force-pushed the greg/service-accounts-v2 branch 2 times, most recently from faf9471 to 92dd836 Compare June 18, 2026 21:45
@GregorShear GregorShear changed the title Greg/service accounts v2 Service Accounts Jun 18, 2026
@GregorShear GregorShear requested review from jshearer and removed request for jshearer June 18, 2026 21:58
@GregorShear GregorShear force-pushed the greg/service-accounts-v2 branch from 92dd836 to 35ff08b Compare June 19, 2026 00:27
.map_err(duplicate_err)?;

for grant in &grants {
crate::grants::overwrite_user_grant(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have mixed feelings about doing this in the create mutation - maybe we need an "update grant" mutation that lets us downgrade capabilities, and the create mutation just upserts (upgrade capabilities only)?

Comment thread crates/models/src/authz.rs Outdated
@GregorShear GregorShear requested a review from jshearer June 19, 2026 00:29
@GregorShear GregorShear marked this pull request as ready for review June 19, 2026 00:29
@GregorShear GregorShear mentioned this pull request Jun 19, 2026
8 tasks

# Run last so its flakiness can't skip the test steps above. It still gates
# the job on failure, surfacing genuine Stripe regressions.
- name: Stripe integration test

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moving this flaky test to the end of the line, at least for now

@GregorShear GregorShear self-assigned this Jun 22, 2026
@GregorShear GregorShear force-pushed the greg/service-accounts-v2 branch from 2052d3d to 03fa4db Compare July 2, 2026 22:02
@GregorShear GregorShear force-pushed the greg/service-accounts-v2 branch from 03fa4db to be5fca4 Compare July 6, 2026 20:52
Comment thread crates/flow-client/control-plane-api.graphql Outdated
Service accounts and users share the same user_grant authz mechanism, so the grant type isn't service-account-specific. Rename the GraphQL output type ServiceAccountGrant -> UserGrant and its input companion ServiceAccountGrantInput -> UserGrantInput. The service-account mutations (addServiceAccountGrant, etc.) keep their names since they describe operations scoped to a service account.
…te/admin

Service-account user_grants now carry capability bundles (the orthogonal authz model) rather than the legacy read/write/admin capability. createServiceAccount and addServiceAccountGrant accept a list of CapabilityBundle per prefix, and UserGrant/UserGrantInput expose bundles in place of capability.

overwrite_user_grant writes the bundles array with the legacy capability column set to 'none', so a grant's authority comes entirely from its bundles (per the orthogonal_capabilities migration). upsert_user_grant, used by the human-user invite flow, is left on the legacy capability path. A grant must specify at least one bundle; an empty set is rejected rather than minting a no-op grant.

CapabilityBundle gains an async_graphql::Enum derive so it can appear in the schema.
Add a capabilityBundles query returning each CapabilityBundle with a display name, description, and the fine-grained capability bits it expands to. Clients (grant pickers) can enumerate the bundles now grantable to service-account user_grants and explain what each confers, mirroring the existing alertTypes query.

Adds all()/display_name()/description() to CapabilityBundle.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants